// Copyright (c) 2014-present, b3log.org
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package session includes session related manipulations.
//
// Wide server side needs maintain two kinds of sessions:
//
//  1. HTTP session: mainly used for login authentication
//  2. Wide session: browser tab open/refresh will create one, and associates with HTTP session
//
// When a session gone: release all resources associated with it, such as running processes, event queues.
package session

import (
	"bytes"
	"encoding/json"
	"errors"
	"math/rand"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"chainmaker.org/chainmaker/smarteditor/container"

	"chainmaker.org/chainmaker/smarteditor/conf"
	"chainmaker.org/chainmaker/smarteditor/event"
	"chainmaker.org/chainmaker/smarteditor/logger"
	"chainmaker.org/chainmaker/smarteditor/util"

	"github.com/88250/gulu"
	"github.com/fsnotify/fsnotify"
	"github.com/gorilla/securecookie"
	"github.com/gorilla/sessions"
	"github.com/gorilla/websocket"
)

const (
	sessionStateActive = iota
	CookieName         = "smart-session-new"
)

// Logger.
var log = logger.Get()

var (
	// sessionWS holds all session channels. <sid, *util.WSChannel>
	sessionWS = sync.Map{} //map[string]*util.WSChannel{}

	// outputWS holds all output channels. <sid, *util.WSChannel>
	outputWS = sync.Map{} //map[string]*util.WSChannel{}

	// notificationWS holds all notification channels. <sid, *util.WSChannel>
	notificationWS = sync.Map{} //map[string]*util.WSChannel{}
	// // HTTP session store.
	HTTPSession *sessions.CookieStore
)

func GetSessionWS(sid string) (*util.WSChannel, error) {
	ws, ok := sessionWS.Load(sid)
	if ok {
		return ws.(*util.WSChannel), nil
	}
	return nil, errors.New("get session ws failed")
}

func GetOutputWS(sid string) (*util.WSChannel, error) {
	ws, ok := outputWS.Load(sid)
	if ok {
		return ws.(*util.WSChannel), nil
	}
	return nil, errors.New("get output ws failed")
}

func GetNotificationWS(sid string) (*util.WSChannel, error) {
	ws, ok := notificationWS.Load(sid)
	if ok {
		return ws.(*util.WSChannel), nil
	}
	return nil, errors.New("get notification ws failed")
}

func GetSessionWSSize() int {
	size := 0
	sessionWS.Range(func(key, value interface{}) bool {
		size = size + 1
		return true
	})
	return size
}

func GetOutputWSSize() int {
	size := 0
	outputWS.Range(func(key, value interface{}) bool {
		size = size + 1
		return true
	})
	return size
}

func GetNotificationWSSize() int {
	size := 0
	notificationWS.Range(func(key, value interface{}) bool {
		size = size + 1
		return true
	})
	return size
}

func SaveSessionWs(sid string, wsChannel *util.WSChannel) {
	sessionWS.Store(sid, wsChannel)
}

func SaveOutputWS(sid string, wsChannel *util.WSChannel) {
	outputWS.Store(sid, wsChannel)
}

func SaveNotificationWS(sid string, wsChannel *util.WSChannel) {
	notificationWS.Store(sid, wsChannel)
}

func DeleteSessionWS(sid string) {
	sessionWS.Delete(sid)
}

func DeleteOutputWS(sid string) {
	outputWS.Delete(sid)
}

func DeleteNotificationWS(sid string) {
	notificationWS.Delete(sid)
}

func NewCookieStore(domain string, keyPairs ...[]byte) *sessions.CookieStore {
	cs := &sessions.CookieStore{
		Codecs: securecookie.CodecsFromPairs(keyPairs...),
		Options: &sessions.Options{
			Path:   "/",
			MaxAge: 86400 * 30,
			Domain: domain,
		},
	}

	cs.MaxAge(cs.Options.MaxAge)
	return cs
}

func InitCookieStore() {
	HTTPSession = NewCookieStore(conf.Wide.CookieHost, []byte("BEYOND"))
}

// WideSession represents a session associated with a browser tab.
type WideSession struct {
	ID          string                     // id
	UserID      string                     // user id
	HTTPSession *sessions.Session          // HTTP session related
	Processes   []*os.Process              // process set
	EventQueue  *event.UserEventQueue      // event queue
	State       int                        // state
	Content     *conf.LatestSessionContent // the latest session content
	FileWatcher *fsnotify.Watcher          // files change watcher
	Created     time.Time                  // create time
	Updated     time.Time                  // the latest use time
}

// Type of wide sessions.
type wSessions []*WideSession

// Wide sessions.
var WideSessions wSessions

// Exclusive lock.
var mutex sync.Mutex

// FixedTimeRelease releases invalid sessions.
//
// In some special cases (such as a browser uninterrupted refresh / refresh in the source code view) will occur
// some invalid sessions, the function checks and removes these invalid sessions periodically (1 hour).
//
// Invalid sessions: sessions that not used within 30 minutes, refers to WideSession.Updated field.
func FixedTimeRelease() {
	go func() {
		defer util.PanicLog()
		for _ = range time.Tick(time.Hour) {
			thirtyMinute, _ := time.ParseDuration("-30m")
			threshold := time.Now().Add(thirtyMinute)
			for _, s := range WideSessions {
				if s.Updated.Before(threshold) {
					log.Debugf("Removes a invalid session [%s], user [%s]",
						s.ID, s.UserID)
					WideSessions.Remove(s.ID)
				}
			}
		}
	}()
}

// Online user statistic report.
type userReport struct {
	userID     string
	sessionCnt int
	processCnt int
	updated    time.Time
}

// report returns a online user statistics in pretty format.
func (u *userReport) report() string {
	return "[" + u.userID + "] has [" + strconv.Itoa(u.sessionCnt) +
		"] sessions and [" + strconv.Itoa(u.processCnt) +
		"] running processes, latest activity [" +
		u.updated.Format("2006-01-02 15:04:05") + "]"
}

// FixedTimeReport reports the Wide sessions status periodically (10 minutes).
func FixedTimeReport() {
	go func() {
		defer gulu.Panic.Recover(nil)

		for _ = range time.Tick(10 * time.Minute) {
			users := userReports{}
			processSum := 0

			for _, s := range WideSessions {
				processCnt := len(s.Processes)
				processSum += processCnt

				if report, exists := contains(users, s.UserID); exists {
					if s.Updated.After(report.updated) {
						report.updated = s.Updated
					}

					report.sessionCnt++
					report.processCnt += processCnt
				} else {
					users = append(users, &userReport{userID: s.UserID, sessionCnt: 1,
						processCnt: processCnt, updated: s.Updated})
				}
			}

			var buf bytes.Buffer
			buf.WriteString("\n  [" + strconv.Itoa(len(users)) + "] users, " +
				"[" + strconv.Itoa(processSum) + "] running processes and [" +
				strconv.Itoa(len(WideSessions)) + "] sessions currently\n")

			sort.Sort(users)

			for _, t := range users {
				buf.WriteString("    " + t.report() + "\n")
			}

			log.Info(buf.String())
		}
	}()
}

func contains(reports []*userReport, userID string) (*userReport, bool) {
	for _, ur := range reports {
		if userID == ur.userID {
			return ur, true
		}
	}

	return nil, false
}

type userReports []*userReport

func (f userReports) Len() int           { return len(f) }
func (f userReports) Swap(i, j int)      { f[i], f[j] = f[j], f[i] }
func (f userReports) Less(i, j int) bool { return f[i].processCnt > f[j].processCnt }

const (
	// Time allowed to write a message to the peer.
	//writeWait = 10 * time.Second

	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second

	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10
)

// WSHandler handles request of creating session channel.
//
// When a channel closed, releases all resources associated with it.
func WSHandler(w http.ResponseWriter, r *http.Request) {
	sid := r.URL.Query()["sid"][0]
	log.Debugf("WSHandler got message ,sid %s", sid)
	if len(strings.TrimSpace(sid)) == 0 {
		return
	}
	conn, err := websocket.Upgrade(w, r, nil, 1024, 1024)
	if err != nil {
		log.Debugf("[ws handler], upgrade http to web socket fial. conn:%v, err:%v", conn, err)
		return
	}
	wsChan := util.WSChannel{Sid: sid, Conn: conn, Request: r, Time: time.Now()}

	ret := map[string]interface{}{"output": "Session initialized", "cmd": "init-session"}
	err = wsChan.WriteJSON(&ret)
	if nil != err {
		log.Errorf("write message to session error.data:%v,err:%s", ret, err)
		return
	}

	SaveSessionWs(sid, &wsChan)
	wSession := WideSessions.Get(sid)
	if nil == wSession {
		httpSession, _ := HTTPSession.Get(r, CookieName)
		if httpSession.IsNew {
			return
		}

		httpSession.Options.MaxAge = conf.Wide.HTTPSessionMaxAge
		err := httpSession.Save(r, w)
		if err != nil {
			log.Errorf("save session failed.sid:%v,user:%s",
				sid, wSession.UserID)
		}
		wSession = WideSessions.newWideSession(httpSession, sid)
		log.Debugf("Created a wide session [%s] for "+
			"websocket reconnecting, user [%s]", sid, wSession.UserID)
	}
	log.Debugf("Open a newWideSession [Session Channel] with session [%s], %d",
		sid, GetSessionWSSize())
	input := map[string]interface{}{}

	err = wsChan.Conn.SetReadDeadline(time.Now().Add(pongWait))
	if err != nil {
		log.Errorf("web socket set dead lien failed.sid:%v,user:%s",
			sid, wSession.UserID)
	}

	wsChan.Conn.SetPongHandler(func(string) error {
		err = wsChan.Conn.SetReadDeadline(time.Now().Add(pongWait))
		if err != nil {
			log.Errorf("web socket set dead lien failed.sid:%v,user:%s",
				sid, wSession.UserID)
		}
		return nil
	})

	ticker := time.NewTicker(pingPeriod)
	defer func() {
		WideSessions.Remove(sid)
		ticker.Stop()
		wsChan.Close()
	}()

	// send websocket ping message.
	go func(t *time.Ticker, channel util.WSChannel) {
		for {
			select {
			case <-t.C:
				if err := channel.Conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
					return
				}
			}
		}
	}(ticker, wsChan)

	for {
		if err := wsChan.ReadJSON(&input); err != nil {
			log.Debugf("[Session Channel] of session [%s] disconnected,"+
				" releases all resources with it, user [%s]", sid, wSession.UserID)
			return
		}
		log.Debugf("read message from session:  %s", input)
		ret = map[string]interface{}{"output": "", "cmd": "session-output"}
		if err := wsChan.WriteJSON(&ret); err != nil {
			log.Error("Session WS ERROR: " + err.Error())
			return
		}
		wsChan.Time = time.Now()
	}
}

// SaveContentHandler handles request of session content string.
func SaveContentHandler(w http.ResponseWriter, r *http.Request) {
	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	args := struct {
		Sid string
		*conf.LatestSessionContent
	}{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		log.Errorf("decode data error, err:%v", err)
		result.Code = -1
		return
	}

	wSession := WideSessions.Get(args.Sid)
	if nil == wSession {
		log.Errorf("save session info, user session not fount, session id:%v", args.Sid)
		result.Code = -1
		return
	}
	user := conf.GetUser(wSession.UserID)
	if user == nil {
		log.Warnf("SaveContentHandler find no user %s", wSession.UserID)
		return
	}
	srcPath := conf.GetSrcDir(user.Workspace)
	var newFileTree []string
	for _, temp := range args.LatestSessionContent.FileTree {
		if strings.HasPrefix(temp, srcPath) {
			newFileTree = append(newFileTree, temp)
		}
	}

	var newFiles []string
	for _, temp := range args.LatestSessionContent.Files {
		if strings.HasPrefix(temp, srcPath) {
			newFiles = append(newFiles, temp)
		}
	}
	var currentFile string
	if strings.HasPrefix(args.LatestSessionContent.CurrentFile, srcPath) {
		currentFile = args.LatestSessionContent.CurrentFile
	}

	args.LatestSessionContent.CurrentFile = currentFile
	args.LatestSessionContent.FileTree = newFileTree
	args.LatestSessionContent.Files = newFiles
	wSession.Content = args.LatestSessionContent

	// update the variable in-memory, session.FixedTimeSave() function will persist it periodically
	user.LatestSessionContent = wSession.Content
	user.Lived = time.Now().UnixNano()
	conf.SaveUserInCache(wSession.UserID, user)
	wSession.Refresh()

}

// SetProcesses binds process set with the wide session.
func (s *WideSession) SetProcesses(ps []*os.Process) {
	s.Processes = ps
	s.Refresh()
}

// Refresh refreshes the channel by updating its use time.
func (s *WideSession) Refresh() {
	s.Updated = time.Now()
}

// GenId generates a wide session id.
func (sessions *wSessions) GenId() string {
	rand.Seed(time.Now().UnixNano())

	return strconv.Itoa(rand.Int())
}

// Get gets a wide session with the specified session id.
func (sessions *wSessions) Get(sid string) *WideSession {
	mutex.Lock()
	defer mutex.Unlock()
	for _, s := range *sessions {
		if s.ID == sid {
			return s
		}
	}
	return nil
}

// Remove removes a wide session specified with the given session id, releases resources associated with it.
//
// Session-related resources:
//
//  1. user event queue
//  2. process set
//  3. websocket channels
//  4. file watcher
func (sessions *wSessions) Remove(sid string) {
	mutex.Lock()
	defer mutex.Unlock()

	//remove session record from session manager
	container.SC.SessionManager.RemoveSid(sid)

	for i, s := range *sessions {
		if s.ID == sid {
			// remove from session set
			*sessions = append((*sessions)[:i], (*sessions)[i+1:]...)

			// close user event queue
			event.UserEventQueues.Close(sid)

			// kill processes
			for _, p := range s.Processes {
				if err := p.Kill(); nil != err {
					log.Errorf("Can't kill process [%d] of session [%s], user [%s]",
						p.Pid, sid, s.UserID)
				} else {
					log.Debugf("Killed a process [%d] of session [%s], user [%s]",
						p.Pid, sid, s.UserID)
				}
			}

			// close websocket channels
			if ws, err := GetOutputWS(sid); err == nil {
				ws.Close()
				DeleteOutputWS(sid)
			}

			if ws, err := GetNotificationWS(sid); err == nil {
				ws.Close()
				DeleteNotificationWS(sid)
			}

			if ws, err := GetSessionWS(sid); err == nil {
				ws.Close()
				DeleteSessionWS(sid)
			}

			// file watcher
			if nil != s.FileWatcher {
				s.FileWatcher.Close()
			}

			cnt := 0 // count wide sessions associated with HTTP session
			for _, ses := range *sessions {
				if ses.UserID == s.UserID {
					cnt++
				}
			}

			log.Debugf("Removed a session [%s] of user [%s], "+
				"it has [%d] sessions currently", sid, s.UserID, cnt)

			return
		}
	}
}

// GetByUserId gets wide sessions.
func (sessions *wSessions) GetByUserId(userID string) []*WideSession {
	mutex.Lock()
	defer mutex.Unlock()

	ret := []*WideSession{}

	for _, s := range *sessions {
		if s.UserID == userID {
			ret = append(ret, s)
		}
	}

	return ret
}

// GetSidByUsername gets wide sessions.
func (sessions *wSessions) GetSidByUsername(userID string) []string {
	mutex.Lock()
	defer mutex.Unlock()

	result := make([]string, 0)
	for _, s := range *sessions {
		if s.UserID == userID {
			result = append(result, s.ID)
		}
	}
	return result
}

// newWideSession creates a wide session.
func (sessions *wSessions) newWideSession(httpSession *sessions.Session, sid string) *WideSession {
	log.Debugf("new wide session. sid:%s", sid)
	mutex.Lock()
	defer mutex.Unlock()

	uid := httpSession.Values["uid"].(string)

	wideSession := newWideSession(sid, uid, httpSession)
	// create user event queue
	wideSession.EventQueue = event.UserEventQueues.New(sid)
	sessions.append(wideSession)

	// add a filesystem watcher to notify front-end after the files changed
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Errorf("new file watcher failed. err: %v", err)
		return wideSession
	}
	wideSession.FileWatcher = watcher

	go sendEvent(sid, watcher)
	go watchUserWorkspace(uid, watcher)
	return wideSession
}

// newWideSession creates a wide session.
func (sessions *wSessions) newTestWideSession(httpSession *sessions.Session, sid string) *WideSession {
	mutex.Lock()
	defer mutex.Unlock()

	uid := httpSession.Values["uid"].(string)

	wideSession := newWideSession(sid, uid, httpSession)
	// create user event queue
	wideSession.EventQueue = event.UserEventQueues.New(sid)
	sessions.append(wideSession)
	return wideSession
}

func (sessions *wSessions) append(session *WideSession) {
	*sessions = append(*sessions, session)
}

func newWideSession(sid, uid string, httpSession *sessions.Session) *WideSession {
	now := time.Now()
	ret := &WideSession{
		ID:          sid,
		UserID:      uid,
		HTTPSession: httpSession,
		EventQueue:  nil,
		State:       sessionStateActive,
		Content:     &conf.LatestSessionContent{},
		Created:     now,
		Updated:     now,
	}
	return ret
}

func sendEvent(sid string, watcher *fsnotify.Watcher) {
	defer util.PanicLog()
	for {
		ch, err := GetSessionWS(sid)
		if err != nil {
			log.Warnf("send file change event, no session found")
			return
		}

		select {
		case fileChangeEvent := <-watcher.Events:
			path := filepath.ToSlash(fileChangeEvent.Name)
			dir := filepath.ToSlash(filepath.Dir(path))
			ch, err = GetSessionWS(sid)
			if err != nil {
				log.Warnf("send file change event, no session found")
				return
			}

			if fileChangeEvent.Op&fsnotify.Create == fsnotify.Create && !gulu.File.IsDir(path) && conf.IsExcludeFile(filepath.Ext(path)) {
				log.Debugf("file change event exclude path. path:%s", path)
				continue
			}

			//log.Debugf("file change event. op:%s, path:%s", fileChangeEvent.Op.String(), fileChangeEvent.Name)

			if fileChangeEvent.Op&fsnotify.Create == fsnotify.Create {
				fileType := "f"
				if gulu.File.IsDir(path) {
					fileType = "d"
					log.Debugf("trace add file to wacther, create event. path:%s", path)
					if err := watcher.Add(path); nil != err {
						log.Warnf("add file to wacther faild, err:%v", err)
					}
				}
				cmd := map[string]interface{}{"path": path, "dir": dir, "cmd": "create-file", "type": fileType}
				err := ch.WriteJSON(&cmd)
				if err != nil {
					log.Warnf("write create event to socket failed, err:%v", err)
				}
			} else if fileChangeEvent.Op&fsnotify.Remove == fsnotify.Remove {
				cmd := map[string]interface{}{"path": path, "dir": dir, "cmd": "remove-file", "type": ""}
				err := ch.WriteJSON(&cmd)
				if err != nil {
					log.Warnf("write remove event to socket failed, err:%v", err)
				}
			} else if fileChangeEvent.Op&fsnotify.Rename == fsnotify.Rename {
				cmd := map[string]interface{}{"path": path, "dir": dir, "cmd": "rename-file", "type": ""}
				err := ch.WriteJSON(&cmd)
				if err != nil {
					log.Warnf("write rename event to socket failed, err:%v", err)
				}
			}
			// else if fileChangeEvent.Op&fsnotify.Write == fsnotify.Write {
			// 	cmd := map[string]interface{}{"path": path, "dir": dir, "cmd": "update-file", "type": ""}
			// 	err = ch.WriteJSON(&cmd)
			// 	if err != nil {
			// 		log.Warnf("write update-file event to socket failed, err:%v", err)
			// 	}
			// }
		case err := <-watcher.Errors:
			if nil != err {
				log.Errorf("file watcher error. err:%v", err)
			}
		}
	}
}

//SendUpdateFileEvent send update file event to frontend
func SendUpdateFileEvent(sid string, path string) {
	if !gulu.File.IsExist(path) {
		log.Debugf("send update file event to frontend, file not exists. sid:%s, path:%s", sid, path)
		return
	}
	log.Debugf("send update file event to frontend. sid:%s, path:%s", sid, path)
	ch, err := GetSessionWS(sid)
	if err != nil {
		log.Warnf("send update file event to frontend, web socket is nil. sid:%s", sid)
		return
	}
	filePath := filepath.ToSlash(path)
	fileDir := filepath.ToSlash(filepath.Dir(filePath))
	cmd := map[string]interface{}{"path": filePath, "dir": fileDir, "cmd": "update-file", "type": ""}
	err = ch.WriteJSON(&cmd)
	if err != nil {
		log.Warnf("write rename event to socket failed, err:%v", err)
	}
}

func watchUserWorkspace(uid string, watcher *fsnotify.Watcher) {
	defer util.PanicLog()
	workspaces := filepath.SplitList(conf.GetUserWorkspace(uid))
	for _, workspace := range workspaces {
		srcPath := filepath.Join(workspace, "src")
		err := filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
			if strings.HasPrefix(info.Name(), ".") || "node_modules" == info.Name() || "vendor" == info.Name() {
				return filepath.SkipDir
			}
			if info.IsDir() {
				log.Debugf("trace add file to wacther, workspace. path:%s", path)
				if err = watcher.Add(path); nil != err {
					log.Warnf("add path to watcher failed, path:%s, err:%v", path, err)
				}
				log.Debugf("File watcher added a dir [%s]", path)
			}
			return nil
		})
		if err != nil {
			log.Warnf("walk path failed. path:%s, err:%v", srcPath, err)
		}
	}
}
